カーディナリティの低い属性にGSIを設定するとスロットリングすることを確認してみた
CX事業本部@大阪の岩田です。
先日とあるDynamoDBのテーブル設計をレビューしていたのですが、GSIにホットパーティションが発生する設計となっていました。このブログではGSIにホットパーティションが発生した際の動作を確認しつつ、改善案について検討してみます。
テーブル設計とアクセスパターン
テーブル設計は以下のような設計でした。簡略化のため、今回の検証に無関係の項目については省略しています。
項目名 | 型 | キー情報 | 備考 |
---|---|---|---|
id | S | テーブルのパーティションキー | UUID4で採番 |
status | S | GSIのパーティションキー | USED とUN_USED の2値をとる新規登録時は常に UN_USED となる |
想定しているアクセスパターンは以下の通りです
id
を指定して1件のアイテムを取得- GSIを利用して
status
がUN_USED
のアイテムをクエリし複数件取得 id
を指定して1件のアイテムを条件付き書き込みstatus
がUN_USED
の場合にstatus
をUSED
に更新する
問題点
このテーブル設計ではstatus
がGSIのパーティションキーとして指定されていますが、status
が取り得る値はUSED
とUN_USED
の2値となっています。このためGSIのパーティションは2つだけになります。また、アイテムの新規登録時はstatus
が常にUN_USED
となるため、アイテム新規登録時の書き込みが全て単一のパーティションに集中することになります。
パーティションあたりのWCU上限は1,000なので、各アイテムのサイズが1K以内に収まる前提で考えると1,000アイテム/秒までしか書き込みできないことになります。
大量のレコードを含むCSVファイルを元にBatchWriteItemでアイテムを一括登録する例で考えてみます。バーストキャパシティとアダプティブキャパシティについては無視して考えると、理論値としては以下のようになります。
- インデックス内の各アイテムサイズは1K以内 → 1アイテムの書き込みでインデックスのキャパシティを1WCU消費する
- BatchWriteItemで25アイテムをまとめて書き込む → 1回のバッチ書き込みでインデックスのキャパシティを25WCUを消費する
- 書き込み対象のパーティションはすべて同一パーティションになる → 最大1000WCPU/sが上限になる
- 1000WCU / 25WCU → 40なので40回のBatchWriteItem/秒が上限となる
アプリケーションの処理でループしながら逐次BatchWriteItemを繰り返すとすると、1回のBatchWriteItemが1秒/40回 → 25ms以内に完了する程度のレイテンシであれば1,000WCUまで達することになります。ローカルPCからの書き込みであれば問題無さそうですが、LambdaやEC2などAWSのNW内から書き込む場合スロットリングエラーを誘発してしまいそうな数値です。
スロットリングエラーが発生しないか検証してみる
実際にスロットリングエラーが発生しないか確認してみましょう。
まずテスト用にテーブルを作成します。テーブル定義のJSONファイルは以下の通りです。
{ "AttributeDefinitions": [ { "AttributeName": "id", "AttributeType": "S" }, { "AttributeName": "status", "AttributeType": "S" } ], "TableName": "test", "KeySchema": [ { "AttributeName": "id", "KeyType": "HASH" } ], "GlobalSecondaryIndexes": [ { "IndexName": "gsi-status", "KeySchema": [ { "AttributeName": "status", "KeyType": "HASH" } ], "Projection": { "ProjectionType": "ALL" } } ], "BillingMode": "PAY_PER_REQUEST" }
上記テーブル定義を元にAWS CLIを使ってテーブルを作成します
aws dynamodb create-table --cli-input-json file://table-def.json
テーブルが作成できたらContributor Insightsも有効化しておきましょう
aws dynamodb update-contributor-insights --table-name test --contributor-insights-action=ENABLE aws dynamodb update-contributor-insights --table-name test --index-name gsi-status --contributor-insights-action=ENABLE
準備ができたらDynamoDBと同一リージョンのCloudShellから検証用コードを実行してみます。
なお、検証に利用した各種のバージョンは以下のとおりです。
- Node.js: v18.18.2
- @aws-sdk/client-dynamodb: 3.549.0
- @aws-sdk/lib-dynamodb: 3.549.0
- uuid: 9.0.1
検証用のコードは以下です。
無限ループしながら25アイテムのBatchWriteItemを繰り返すという実装です。
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); const { BatchWriteCommand } = require('@aws-sdk/lib-dynamodb'); const { v4 } = require('uuid'); const tableName = 'test'; const createReqItems = () => { const items = new Array(25).fill(null).map(() => ({ PutRequest: { Item: { id: v4(), status: 'UN_USED' } } })) return { [tableName]: items } } (async () => { console.log(`start ${new Date()}`) const ddbClient = new DynamoDBClient(); let items = 0; let errItems = 0; while (true) { const requestItems = createReqItems(); items += requestItems[tableName].length; const batchWriteCmd = new BatchWriteCommand({ RequestItems: requestItems }) const res = await ddbClient.send(batchWriteCmd) const unprocessedItems = res.UnprocessedItems if(Object.keys(unprocessedItems).length !== 0) { errItems += unprocessedItems[tableName].length; console.error(`${new Date()} UnprocessedItems: ${errItems} / ${items}`) } } })();
CloudShellから上記コードを実行します
node index.js
テスト結果
実行後しばらくするとエラーログが出力されるので適当なところでCtrl + Cで実行を停止しましょう。エラーログからBatchWriteItemが一部失敗していることが分かります。
... Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 166 / 160350 Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 167 / 160375 Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 171 / 160400 Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 173 / 160425 Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 175 / 160450 Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 177 / 160475 Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 179 / 160500 Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 180 / 160525 Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 184 / 160550
テスト実施時間帯のCloudWatchメトリクスを確認してみましょう。
まずContributor Insightsのメトリクスです。テーブル自体のパーティションキーは「消費されたスループットユニット」が分散しており、値もせいぜい50程度です。それに対してGSIのパーティションキーは全て単一パーティションにアクセスが集中しており、「消費されたスループットユニット」 は約350kに達しています。GSIの最もスロットリングされたキー (パーティションキー)
には特にデータが計上されていませんが、これはContributor Insightsの仕様によるものでGSIの書き込みスロットは計測されないためです。
グローバルセカンダリインデックスの書き込み容量が不十分なため発生した書き込みスロットルは測定されません。
CloudWatch DynamoDB のコントリビューターインサイト:仕組み - Amazon DynamoDB
対象GSIのメトリクスWriteThrottleEvents
を確認するとスロットリングが発生していることが分かります
GSIの設計を改善してみる
GSIのスロットリングエラーが発生することが確認できたので、GSIの設計を改善してみましょう。改めてGSIに関するアクセスパターンを抽象化して考えると要件は以下の通りと考えられます。
- 「未使用」状態のアイテムを複数件取得したい
- 「未使用」状態のアイテムを「使用済み」に更新したい
この要件を満たすようにDynamoDBらしい設計に置き換えると以下のようになります。
項目名 | 型 | キー情報 | 備考 |
---|---|---|---|
id | S | テーブルのパーティションキー | UUID4で採番 |
unUsedId | S | GSIのパーティションキー | 「未使用」状態のアイテムの場合はid と同じ値を登録新規登録時は常に「未使用」状態として id を登録する「使用済み」状態のアイテムの場合は属性ごと指定しない |
この設計はスパースインデックスというテクニックを使っており、unUsedId
という属性が存在するアイテムにのみ対応するインデックスが作成されます。先ほど確認した要件は以下のようなオペレーションで満たすことができます。
- 「未使用」状態のアイテムを複数件取得したい
- → GSIをスキャンする
- 「未使用」状態のアイテムを「使用済み」に更新したい
- →
id
を指定して1件のアイテムを条件付き書き込み unUsedId
が存在する場合はunUsedId
を削除するように更新する
- →
新しいテーブル定義は以下の通りです。
{ "AttributeDefinitions": [ { "AttributeName": "id", "AttributeType": "S" }, { "AttributeName": "unUsedId", "AttributeType": "S" } ], "TableName": "better-table", "KeySchema": [ { "AttributeName": "id", "KeyType": "HASH" } ], "GlobalSecondaryIndexes": [ { "IndexName": "gsi-un-used-id", "KeySchema": [ { "AttributeName": "unUsedId", "KeyType": "HASH" } ], "Projection": { "ProjectionType": "ALL" } } ], "BillingMode": "PAY_PER_REQUEST" }
先ほどと同様テーブルの作成とContributor Insightsの有効化を行います。
aws dynamodb create-table --cli-input-json file://better-table-def.json
aws dynamodb update-contributor-insights --table-name better-table --contributor-insights-action=ENABLE aws dynamodb update-contributor-insights --table-name better-table --index-name gsi-status --contributor-insights-action=ENABLE
先程利用した検証用コードを新しいテーブル定義に合わせて少し書き換えます
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); const { BatchWriteCommand } = require('@aws-sdk/lib-dynamodb'); const { v4 } = require('uuid'); const tableName = 'better-table'; const createReqItems = () => { const items = new Array(25).fill(null).map(() => { id = v4(); return { PutRequest: { Item: { id, unUsedId: id } } } } ) return { [tableName]: items } } (async () => { console.log(`start ${new Date()}`) const ddbClient = new DynamoDBClient(); let items = 0; let errItems = 0; while (true) { const requestItems = createReqItems(); items += requestItems[tableName].length; const batchWriteCmd = new BatchWriteCommand({ RequestItems: requestItems }) const res = await ddbClient.send(batchWriteCmd) const unprocessedItems = res.UnprocessedItems if(Object.keys(unprocessedItems).length !== 0) { errItems += unprocessedItems[tableName].length; console.error(`${new Date()} UnprocessedItems: ${errItems} / ${items}`) } } })();
このコードをCloud Shellから実行します
node index2.js
しばらくしたら適当なところでCtrl + Cで実行を止めてメトリクスを確認しましょう。
テスト結果
まずはContributor Insightsです。改善前と異なりGSIの「消費されたスループットユニット」が複数のパーティションに分散しており、約60未満の低い水準で安定していることが分かります。
続いてGSIのWriteThrottleEvents
を確認してみましたが、こちらはスロットリングが発生していないためメトリクス自体が存在しませんでした。ConsumedWriteCapacityUnits
の1分当たりの合計値は156,300と改善前より若干上がっていました。これもスロットリングを回避できたことが影響してそうですね。
補足ですが、この設計のもう1つのメリットとして「使用済み」状態に更新されたアイテムについてはインデックスが削除されるため、ストレージ容量に対する従量課金が低く抑えられるというメリットもあります。
まとめ
カーディナリティの低い属性に対して素直にGSIを設定するとホットパーティションが生まれ、スロットリングを誘発するリスクがあることを確認しました。RDBに慣れていると気持ち悪く感じるかもしれませんが、DynamoDBのテーブル設計においてはスパースインデックス等のテクニックをうまく活用していくのが良いでしょう。